iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
AI Engineering

Backend 工程師的 Azure GenAI 實戰系列 第 9

Day 9:Token Budget 與成本治理——別估算,計帳;別事後心痛,事前拒絕

  • 分享至 

  • xImage
  •  

LLM 後端最常見的成本事故不是單價貴,是沒有任何一層在花錢之前說不:一個迴圈重試、一段失控的長回覆、一場沒人記得關的對話,帳單月底才告訴你。今天要把成本從「月底的驚嚇」變成「後端的合約」:每一輪呼叫回報它花了多少、每一段對話有生命週期預算、超額的請求在碰到上游之前就被擋下。讀完你會有兩道可執行的花費邊界,和一份三週實測的第一手帳單當基準。

先交代這份帳單,因為它是今天所有設計的背景。

基準數字:三週、九個里程碑、新台幣 0.02 元

這個系列從 Day 4 開通 Azure OpenAI 資源到今天,所有里程碑的 live smoke test 都打真模型(gpt-5-mini,japaneast,GlobalStandard)。用 Cost Management 查詢 2026-07-01 到 07-22 的實際帳單(az rest 打 Cost Management Query API,同時抓花費與計量數量,2026-07-22 查詢;成本資料入帳通常滯後數小時到一天,07-21/22 的大量 smoke 用量尚未入帳,所以下面的量看起來很小):

Meter 計量數量(1M tokens 為單位) 花費 (TWD) 佔比
GPT 5 Mini Input 0.000062(= 62 tokens) 0.0005 2%
GPT 5 Mini Output 0.00037(= 370 tokens) 0.0238 98%
合計 0.0243

「98% 是 output」描述的是這個時間窗已入帳的 meter 佔比,不代表一般 workload 的通則,但它背後的兩個機制(單價差與 reasoning 計費)是通則。

https://ithelp.ithome.com.tw/upload/images/20260809/20168288Cv2tdrrv3D.png
忍喵:「三週、九個 milestone、新台幣兩分錢——每個 create script 都帶 teardown、每個資源都活不過它的用途。錢是治理出來的,不是求出來的。」

兩分錢當然便宜,但這張表上有一個不成比例的數字值得停下來:98% 的花費是 output。我們的 smoke test 輸入都比輸出長(整段 replay history 進 input),為什麼帳單反過來?兩個原因疊加:

  1. 單價差 8 倍:從上表直接重算,input 0.00049839 ÷ 0.000062 = TWD 8.04/1M tokens,output 0.02379421 ÷ 0.00037 = TWD 64.31/1M tokens,比值 8.0006。
  2. 你看不見的 token 也是 output:gpt-5-mini 是 reasoning model,回答前產生的 hidden reasoning tokens 不會出現在回覆內容裡,但計入 output 計費(官方 reasoning models 文件,查核 2026-07)。

第一點可以跟牌價互相驗證:官方定價頁的 gpt-5-mini Global 牌價是 $0.25 input/$2.00 output per 1M tokensAzure OpenAI 定價頁,查核 2026-07),比值一致,隱含匯率約 32 也合理。你的實際費率,用同一招從 Cost Management 反推最不會錯。

第二點用今天的 live smoke 立刻可以看到。請模型「Reply with exactly: pong」,回覆四個字母;為了把 hidden reasoning 攤在帳面上,我們把 usage.output_tokens_details.reasoning_tokens 也接了進來。同參數打數次,其中一次的 usage(從完整 SDK usage 中節錄本文相關欄位;數值逐欄對應留存的 evidence,30 + 79 = 109 自洽):

{"input_tokens": 30, "output_tokens": 79,
 "output_tokens_details": {"reasoning_tokens": 64}, "total_tokens": 109}

79 個 output tokens 裡,64 個是 reasoning;可見的「pong」加上結構開銷只佔 15 個。

同一批的另一次同參數呼叫則回報 output_tokens: 64reasoning_tokens: 0(30 + 64 = 94,同樣自洽):一樣是一個字的回覆,aggregate 高達 64、detail 卻說沒有 reasoning,這次歸因不出來。這個對比本身就是重要教訓:usage 是 request 層級的觀測訊號,detail 是歸因線索;計費以 aggregate meter 為準,兩者不保證每次都能對到帳

https://ithelp.ithome.com.tw/upload/images/20260809/20168288QiDrHg8YyX.png
忍喵:「回覆 4 個字母,output 79 tokens、64 個是 reasoning——你付錢的大宗是模型『想』的部分,而它連一個字都沒給你看。估 token 的人估得到這個?」

估算 vs 計帳:為什麼不引 tokenizer

面對成本,多數教學的直覺是拿 tiktoken 之類的 tokenizer 在本地估 token 數。我們否決了這條路,理由有三:

  • 估不準的部分正好是最貴的部分。reasoning tokens 在請求發出之前根本不存在,任何本地估算都碰不到它,而它是我們帳單的大宗。
  • 維護負擔。tokenizer 版本要跟模型對應,模型換代(這個系列已經歷過 naming 從 Azure OpenAI 到 Microsoft Foundry 的變動)就是一次靜默漂移的機會。
  • 上游已經回報了它量到的數字。Responses API 每個回應帶 usage block:input_tokensoutput_tokens(含 output_tokens_details.reasoning_tokens)、total_tokens

usage 的定位要先講清楚:它是 request 層級的計量訊號,適合歸因與 guardrail;帳務的權威永遠是發票與 Cost Management 的 meter 紀錄官方成本管理文件明言以 invoice 為 source of truth,查核 2026-07),別把它包裝成可逐筆對帳的財務資料。

所以原則是:metering over estimation。應用程式不猜任何數字,只負責把上游回報的訊號送到三個該去的地方:API contract、log、與預算的帳本。

usage 進 contract:body、終端事件、log 三方對齊

第一個去處是 API contract。/api/v1/chat 的回應多了三個欄位:usage(含 reasoning_tokens)、statusincomplete_reason。今天的 live smoke:

{
  "message": "pong",
  "conversation_id": "d955dc7e-92f6-4dd3-b000-fe1508846e7d",
  "correlation_id": "day09-fix-2",
  "usage": {"input_tokens": 30, "output_tokens": 57, "total_tokens": 87, "reasoning_tokens": 0},
  "status": "completed",
  "incomplete_reason": null
}

statusincomplete_reason 是把 Day 6 streaming 終端事件的語意鏡射到非串流端點。這其實是實作 max_output_tokens 時撞到的一個必答題:cap 也作用在非串流呼叫上,但 /chat 原本的 contract 沒有任何欄位能說「這個回覆被截斷了」。不處理的話,截斷會被偽裝成正常成功回給 client、還照常 commit 進歷史。

所以規則整組搬過來:max_output_tokens 截斷 client 保留部分文字、turn 照常 commit;content_filterother client 必須丟棄、turn 不 commit(首輪拿到的 conversation_id 也就從未存在,與 streaming header id 的 provisional 語意一致)。用 LLM_MAX_OUTPUT_TOKENS=16 對真模型逼出來的截斷長這樣(連 16 個 tokens 都不夠模型「想」,可見文字是空的,但 contract 誠實說了):

{"message": "", "status": "incomplete", "incomplete_reason": "max_output_tokens",
 "usage": {"input_tokens": 32, "output_tokens": 0, "total_tokens": 32, "reasoning_tokens": 0}}

streaming 端 usage 掛在 message.done 終端事件上;delta 不帶 usage,因為只有終端回應才結算這一輪的量:

event: message.done
data: {"status": "completed", "correlation_id": "day09-final-s", "usage": {"input_tokens": 30, "output_tokens": 51, "total_tokens": 81, "reasoning_tokens": 0}}

這是 Day 6 詞彙表設計還債的時刻:當時定下「client 必須忽略未知欄位與事件名」,所以 usage 是 additive change,舊 client 一行都不用改。

多輪對話下 input 會逐輪變大:store=False 之下每輪重送全部 replay history(Day 7)。若每輪新增內容量大致固定、每輪完整重送歷史,而且沒有截斷或 compaction,單輪 input tokens 會隨輪數線性增加,累計 input tokens 則近似二次成長。這就是預算要以「對話」為單位的原因,下一節回來。

第二個去處是 log。adapter 對每個帶回 usage 的終端寫一行,也就是非串流成功與 stream 的 completedincompleteservices/azure_openai.py):

llm usage input_tokens=30 output_tokens=57 reasoning_tokens=0 total_tokens=87 correlation_id=day09-fix-2

它跟 Day 8 的 prompt-attribution 行(prompt_name=… prompt_version=… correlation_id=…)用同一個 correlation id join:事故時你可以回答「這個 request 用了哪版 prompt、量到多少 token」,而 client 端拿到的 usage 也對齊同一個 id。

範圍要說老實話:這條歸因鏈只覆蓋「有 usage 終端可讀」的呼叫。response.failed、SDK exception、client 斷線這些路徑可能已經在上游發生了可計費的處理,但沒有終端 usage 可記——log 行缺席不代表零成本,對帳仍以 Cost Management 為準。另外它是歸因不是聚合,「這個月哪個 prompt 版本最燒錢」是 Cost Management 與 Day 27 Application Insights 的工作,不是 grep 的。

兩道邊界:截斷單次回覆、拒絕超額對話

有了計帳,才談得上邊界。這個 backend 架了兩道,作用的尺度不同:

機制 邊界尺度 觸發時的行為
max_output_tokensLLM_MAX_OUTPUT_TOKENS,預設 1000) 單次回覆 兩個端點都回報 incompletemax_output_tokens(streaming 走 message.done、非串流走 status 欄位);client 保留部分文字(Day 6 規則)
conversation token budget(CONVERSATION_TOKEN_BUDGET,預設 50000) 整段對話 429 token_budget_exceeded,在呼叫上游之前拒絕

第一道很便宜:每次上游呼叫都帶 max_output_tokens。沒有上限的回覆是燒預算最快的方式(尤其 reasoning tokens 也算在這個上限內),而 Day 6 早就為「被截斷」定好了詞彙,上一節也把它鏡射到了非串流端點。當時是理論上的路徑,今天起是會真實發生的路徑。順帶一提,兩個設定都做 fail-fast 驗證:0 或負值在啟動就被拒絕(負的 cap 會把部署錯誤推遲成難解的上游 400),None 是關閉預算的唯一寫法。

第二道是今天的主角。每段對話有一本帳(Conversation.total_tokens),每輪 turn-commit 時把上游回報的 total_tokens 一起寫入——帳與 turn 在同一次 append 裡原子提交,不會出現「歷史寫進去了、帳漏了」的漂移。

檢查則發生在新一輪 inference 之前(services/conversation.py):

    def _check_budget(self, conversation: Conversation) -> None:
        # Post-paid ledger, pre-paid gate: the check reads what committed turns
        # actually reported, so it can only fire *between* turns — a single turn
        # can still overshoot the line by up to one call's worth of tokens
        # (bounded by max_output_tokens plus the history the turn replays).
        if self._token_budget is None:
            return
        if conversation.total_tokens >= self._token_budget:
            raise TokenBudgetExceededError(
                conversation.id, conversation.total_tokens, self._token_budget
            )

guardrail 的重點在「之前」兩個字:被拒絕的那一輪不會發出模型 inference 呼叫。單元測試用假的 chat service 證明這條控制流:超額對話再送一輪,service 的呼叫次數維持不變。

被拒絕的請求走 Day 3 以來的同一個 error envelope:

{
  "error": {
    "code": "token_budget_exceeded",
    "message": "This conversation's token budget is exhausted; start a new conversation."
  },
  "correlation_id": "443bb065-7e3e-4e11-b65c-a9ba4c15f4de"
}

三個語意細節值得說明白:

  • 429,但沒有 Retry-After。這個預算是對話的生命週期上限,不隨時間回補。等待不是解法,開新對話才是。跟上游 quota 的 429(我們映射成 503 upstream_throttled,那是「稍後再試」)語意不同,所以是兩個不同的 error code。
  • streaming 一律 pre-stream 拒絕。預算檢查跟對話查找在同一個階段、發生在 eager open 之前(Day 6 的兩段式邊界),所以超額永遠是乾淨的 HTTP 429,不會走到「200 之後 SSE error」那條路。
  • post-paid ledger 的精度誠實講:帳本記的是已提交輪次的實際帳,檢查只能在輪與輪之間發動;單一輪仍可能越線,越幅上限是一次呼叫的量(max_output_tokens 加上該輪重送的歷史)。要「絕不越線」就得回頭做 pre-paid 估算,而那條路的問題上一節講過了。工程上這是個划算的交換:精度讓一步,換到不猜數字、不養 tokenizer。

reject、truncate,那 degrade 呢

超額的三種反應裡,我們選了兩種:對話尺度 reject(429)、回覆尺度 truncate(max_output_tokens)。第三種 degrade(接近預算時換便宜模型或縮 context)這次明確不做,理由是它需要的基礎設施今天都還不存在:路由到第二個 deployment、對「品質下降」的產品決策、以及讓 client 知道「這個回覆是降級版」的 contract 欄位。在單一模型、單一 prompt 的現階段,degrade 是過度設計;等 Day 16 之後有多模型路由的場景,它才有掛載點。

同樣列出來就不做的還有 per-user quota:現在的 API 沒有身份(Day 19 才有認證),「per-user」在技術上不存在。對話預算是沒有身份時能做到的最誠實粒度:它至少保證單一對話的無限成長會被止住。

誠實揭露:這本帳不是你的財務報表

  • 失敗的 turn 可能已產生可計費的上游處理,但不進 ledger,也可能沒有 usage log 行。Day 7 的 turn-commit 語意是「失敗的輪次不留痕跡」,這對歷史正確性是對的,對帳務完整性則有缺口:upstream 錯誤、被丟棄的 content_filter 回覆、斷線,這些路徑可能已花掉 token,帳本與 log 卻不知道。我們選擇讓 turn-commit 優先,並把邊界說清楚:權威的花費紀錄是 Cost Management,ledger 的職責是止血,不是財務對帳
  • in-memory ledger 重啟歸零,跟 Day 7 的對話本體一個命運。persistent store 落地時(append 的條件寫入契約已備好),ledger 隨對話一起遷移。
  • 預設值是示範值。50000 tokens 上限、1000 output 上限,都是為了讓 BDD 情境跑得動的量級,不是任何生產建議:你的數字得從你的 usage log 長出來。

最後把邊界放回全景,而且要先拆一個危險的誤解:Azure 的 budget alert 不是花費上限。官方文件寫得很直白:超過門檻只會發通知,「資源不受影響、消費不會停止」,而且成本資料本身就滯後數小時、預算每 24 小時才評估一次(官方 budgets 文件,查核 2026-07)。

要真的自動停用資源,得另外接 action group automation,那有自己的失敗模式,本系列不做。

所以三層的分工是:應用程式的 guardrail 管燒錢速率(per-call、per-conversation,即時、在花錢之前);deployment quota(TPM)管吞吐(但限流不是限費);訂閱層的 budget alert(本系列 Day 4 起的 US$20 genai-lab-monthly-cap)是延遲的偵測底線,它不會替你擋下任何一塊錢,只保證失控不會無聲無息地跑完一整個月。

今天的成果

day-09 tag(repo,CI 綠):

  • usage(含 reasoning_tokens)進 /chat body 與 message.done(additive),與 log 行以 correlation id 對齊
  • /chat 新增 statusincomplete_reason:非串流端點鏡射 Day 6 終端語意,截斷不再能偽裝成功;commit 規則同步(content_filter/other 不 commit)
  • max_output_tokens 上每一次上游呼叫;conversation token budget 於 inference 前檢查,429 token_budget_exceeded envelope;兩個設定 0/負值啟動即拒
  • ledger 與 turn 同一次 append 原子提交;34 個新單元測試 + 6 個新 BDD 情境(含「拒絕發生在上游呼叫之前」「截斷如實回報」)
  • live smoke vs chat-mini:usage 對齊、tiny budget 第二輪 429、LLM_MAX_OUTPUT_TOKENS=16 逼出真實 incomplete/max_output_tokens

用到的 Azure 服務:Azure OpenAI(gpt-5-mini via Responses API)、Azure Cost Management(帳單查詢與 budget alert)。

下一篇是這條防線的向外一層:application guardrail 管的是「自己人好好花錢」,擋不了 key 外洩後別人幫你花。Day 10 用 Azure API Management 把 API key 收進閘道後面,加上真正 per-client 的 rate limiting。


本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。


上一篇
Day 8:Prompt Template 與版本管理——prompt 是生產資產,不是程式碼裡的字串常數
下一篇
Day 10:用 API Management 收編 model 憑證——app guardrail 管自己人,gateway 管所有人
系列文
Backend 工程師的 Azure GenAI 實戰12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言